查看原文
其他

客户案例分析:Airbnb单体到微服务改造之旅(1)

薛以致用 AWS 架构师之旅 2019-12-18

很多客户都关注如何改造遗留系统到现代化微服务架构,本文从 Airbnb 公开技术资料解析,Airbnb 这样一个快速崛起的共享经济代表的全球化站点,在微服务改造过程中的各种取舍和落地实践。

一共有两篇,总体的文章结构如下:

  • 第一篇

    • 背景 

    • 服务改造第一步:指明方向,统一认识

    • 微服务改造第一次尝试:选择一个核心业务

    • 加速微服务开发:服务接口定义和代码自动生成

    • 小结

  • 第二篇

    • 健壮性和可管理性:服务标准化和最佳实践强化

      • 请求/响应的上下文信息  

      • 标准服务指标和仪表盘

      • 服务API警报

    • 服务容错和自愈能力

      • 服务器端请求的异步处理

      • 请求队列

      • 重试风暴

      • 基于客户端的请求限额控制

      • 依赖隔离和优雅降级

      • 云主机异常检测和处理

    • 总结

背景

Airbedandbreakfast.com 在2008上线,在 2009年站点缩写成 Airbnb.com,公开资料显示 2014年仅有90名左右的员工,但随着业务的快速增长,到2018年,仅仅4年时间,他们的工程师数量超过了1000名;原本的站点是一个非常典型的 Ruby on Rails 单体架构,意味着 MVC 三层在唯一的代码库中,底层是一个集中式的数据库。业务和工程师团队的增长意味着,代码行不断膨胀,从2014年50万行左右到2018年400万行代码。由于单体应用架构及其简单,因此,有关于部署运维的基础设施层的开发和运维响应是依赖于“志愿者”模式,我们整个站点依靠这样志愿者系统运维人员组成的小团队。那为什么要走向艰难的万里长征般的SOA微服务化之旅呢?

紧耦合,随着工程师团队人数的不断增长,组件功能变得日益复杂,组件之间高度互相依赖,整个代码的更新,调试,和部署演变成一个深受挫折的事情,而工程师最理想的提交代码的时间是在凌晨,其他人都没上班的时候,避免太多的代码合并冲突。

代码膨胀,增长之殇,不断增加新功能,但整个应用共享一个数据库,数据库变得不是那么稳定可靠;2015年,大概200人的团队每天提交200次代码变更,平均每周遇到15个小时的故障时间而没法部署,主要由于各种事故或回滚。

多团队同时负责一个页面的不同功能模块导致难以执行的主人翁精神和职责不清,有一个消息模块有400多位工程师提交过代码更新,总计8000多行代码。

        故障和事故频发,导致工程师团队效率低下,很常见的是,一个工程师仅仅修改了一个本地化的功能,却导致其他无关的功能不可用;另外部署的整个过程变得缓慢,到了数小时量级。

因此为了解决这些痛点,Airbnb转向了微服务,每个服务都可以独立开发部署扩展,团队之间的职责边界从而变得非常清晰;但是一个页面可能需要很多个微服务协作才能完成所有功能,这样的复杂度真的能顺利实现和落地?我们看看 Airbnb 如何华丽转型的。

微服务改造第一步:指明方向,统一认识

微服务改造是涉及所有人的一项工程,因此,统一认识至关重要,只有大家有一个标准的构建服务的方式,才能被团队快速采用和快速推广。

第一个原则,服务只能读写自己的数据库;也就是说如果有不同的服务都关注同样的数据集,那么它们只有通过一个gatekeeper 服务的API进行访问。这个原则确保数据的一致性、隔离和封装。

第二个原则,服务必须专注某个领域,避免功能重叠;需要避免拆分单体,但将过多的功能放到另外一个服务变成一个新单体;同时,我们也不希望把服务拆成太多的小服务;因此,需要在这两者之间找到一个平衡,为了避免单体里面的代码共享和复制问题,我们选择实现共享服务层。

第三个原则,数据变化需要通过标准事件进行发布;这条是从我们的代码中,发现有很多回调函数,这些回调函数是一个对象生命周期中不同状态变化的一个钩子;比如一旦一个住宿预订成功,我们需要立刻标注该房间在这些日期不可预订。单体拆分成微服务之后,就无法使用回调函数,因此Airbnb 实现了一个SpinalTap(已开源)的CDC(Change Data Capture)服务,该服务监听数据库的各种变化,并将它们发布到Kafka消息队列中,其他服务都可以监听和消费这些标准事件。


==扩展阅读==,目前数据库的 CDC 服务应用场景非常广泛,常见的开源解决方案有:

  • Airbnb 开源的 SpinalTap

  • Debezium https://debezium.io/

  • http://maxwells-daemon.io/

  • 阿里巴巴 MySQL binlog 增量订阅&消费组件 canal 

    •  https://github.com/alibaba/canal

微服务改造第一次尝试:选择一个核心业务

微服务改造必然不是一蹴而就的事情,是不断学习和迭代的过程,从哪里开始就显得非常重要,很多企业的一个误解是选择一个边缘业务积累经验在进行推广,真正的时间投入一定是最重要的业务产出越大,因此,Airbnb选择从他们当时最核心的业务“home”开始尝试,该模块几乎被所有其他业务所访问,如果能改造成功,那么对于后续的改造就会更有信心。

        初步的设想是用新的 Home RPC 服务接口替换掉代码里面访问 Home 相关数据的调用,但发现代码中有太多这样的调用,因此,我们没法手工找出所有的调用入口并改成RPC服务调用;这个促使我们思考从更底层的数据访问层入手,从 Ruby metaprogramming 入手重新实现真正的数据访问层逻辑。

      Rails’ ActiveRecord 封装了一些数据访问模型,比如图上所示的 home.find_by_host_id 进而它被翻译成一个Ruby库支持的业务实体读写并持久化到数据库的 ActiveRecord Wrapper对象,ActiveRecord会把该方法转化成原生的SQL语句 select * fromhomes where host_id = 4 该语句会发送到 ActiveRecord adapter 层,默认该 adapter 层会将请求发送给数据库;因此明显的一个改造点就是定制一个 ActiveRecord adapter 实现,不直接发送到数据库,我们构造一个Request 对象,并发送到 HomeRPC Service;这看起来有点绕,但通过引入这样一个定制实现的ActiveRecord adapter 层,我们可以捕捉到原生的SQL语义,从而可以实现一个非常灵活的RPC服务API来支撑这些在单体应用中的用户场景;并且不需要在单体代码中手工替换掉成千上万的函数调用到RPC服务调用;从底层进行重构不会改变工程师目前的工作流程,而且可以透明的切换到了新的Home RPC服务调用。

      随着新的RPC服务引入,服务分层和调用依赖关系必须要理清楚,Airbnb的服务调用发起方是最终用户,迁移阶段的中间状态,用户先发送请求到Monorail 单体应用,未来会构建一层API 网关来路由所有的服务调用;服务分层方面,如上所述,我们改造是从数据层向应用层进行迭代,因此我们划分了不同的微服务类型:

  • 数据服务(Data Service):直接和数据库打交道,并确保只能读写自己业务域的数据

  • 派生数据服务(Derived Data Service):共享数据服务层,可以读取多个业务域的数据服务,并具有一定业务逻辑,可在多个的产品上下文中共享。

  • 数据验证服务(Data Validation Service):和数据服务配套使用,数据服务只关注数据读写本身,所有业务逻辑和验证都有数据验证服务实现。

  • 展现层服务(Presentation Service):通过访问数据服务和派生数据服务,组装成展示层所需要的信息给到最终用户。

举个例子,上图的用户下单页面,依赖住宿(Home)服务和预定(Reservation)服务,住宿数据服务和预定数据服务分别负责各自的数据持久层数据库,而派生的住宿数据服务,需要获取特定预定日期或地点的住宿信息,离线的预定趋势统计数据,然后它展示给用户一个likehood的趋势信息;如果最终用确定下单预定,那预定数据验证服务会根据业务逻辑验证该预定是否有效,最终由预定数据服务写入数据库。

有了以上的架构理论准备,就可以开始进入实施阶段,最重要的一点就是确保不会打破原有的业务逻辑,因此Airbnb采取一个比较稳妥的办法,区分读和写,从读开始,比较单体和新数据服务之间的差异,并逐步并行上线最后用新服务完全取代

      同时双读的请求是冥等的,这样我们就可以比较同样的请求,原来的单体返回的结果和新的数据服务返回的结果是否一致来判断新服务是否有缺陷,我们将双读的配置简化到了管理页面,这样工程师无需修改一行代码就可以打开或关闭双读功能。我们从1%的流量开始,观察两个路径的请求结果是否有差异,然后缓慢增加流量到 5%,10%,25%,50%再到100%,每一步都进行收集数据,对比,在 100% 双读流量下,我们还继续等待一段时间,以便覆盖更全面的用户访问模式,最后才把双读进行切换到单独读新服务。

写请求的替换和读替换略有不同,不可能双写同一个数据库,因此,双写验证需要引入一个影子库,并引入一个中间数据验证服务,该服务调用数据服务写数据到影子库,并在写后进行发送强一致性读请求到生产库和影子库,并对比两者的差异性;整个工作流跟读切换流程类似。

基本的迁移和切换方法都已经准备好,如何扩展到整个产品组呢?在Airbnb我们依然采取保守的分步实施策略,一次迁移一个Endpoint,比如/loadUsers,这种方式可以帮助依赖该 Endpoint 的内部团队继续他们的工作,另外也可以有时间留给改造团队进行更多服务功能研发。还有一种迭代方法是根据展现层需要的数据模型属性级别进行分层,比如一个展现逻辑需要10个属性信息,但其中3个是在改造好的微服务里实现了,那怎么做呢?我们会将这三个属性从新的服务获取,剩下的依然从单体应用中来,这样的分层允许我们一次只做有限的更新。

采用标准的测试和部署流程,其中流量回放功能,对于我们衡量新服务质量是非常重要的保障。所谓流量回放就是指,客户端发出一个请求,我们克隆一份并发送到内部的其它目标节点;在 Airbnb 我们采用了 twitter 开源的Diffy工具,它将复制的请求作为输入,发送给三个目标,一个是 Stage 环境的新代码,另外两个都是跑旧代码的 Primary 和 Secondary;基于此架构,Diffy会比较 Stage和 Primary 的响应结果的差异,以及 Primary 和 Secondary 环境响应结果的差异。这些差异结果和噪音可能都是我们在Stage环境中的新代码更新导致的;Diffy 可以帮到团队判断新的代码是否破坏了原来的功能,或者是否修复了旧代码的一个缺陷。

基于这样的尝试我们已经学的不少实践经验,接着我们再来看看Airbnb在微服务过程中总结出的一些最佳实践。

加速微服务开发:服务接口定义和代码自动生成

Airbnb 后台微服务大多数基于Java,利用 Dropwizard https://www.dropwizard.io Web 服务框架,加上Airbnb定制的各种过滤器和模块来固化后台服务的最佳实践。另外,公司内部的 make-me-a-service 工具帮助工程师快速生成一个Dropwizard的应用脚手架,基于该脚手架团队可以直接增加需要的REST服务或者JSON-over-HTTP 服务终端。但是这套脚手架缺少一些微服务领域必要的能力:

  • 无法定义强类型的服务接口和数据 Schema:通常很难知道该服务是做什么的,该怎么调用,响应结果是什么格式;另外由于缺少客户端和服务端的契约验证,在过往中发生过由于服务接口变更导致的生成事故。

  • 一个 REST服务框架 Dropwizard缺少远程服务调用方法:开发人员需要花费不少时间为服务同时开发维护Ruby和Java的客户端。好的远程服务调用远远不仅仅指一个HTTP客户端Wrapper,它应该包含标准的基础设施需求和平台最佳实践,例如传入请求的上下文对象,衡量请求性能指标,传播服务异常,支持双向TLS加密,支持服务的监控和告警,这些都需要每个团队额外的工作量。

  • JSON格式数据的Request和Response相对于二进制压缩的数据序列化而言,显得笨重且低效。

REST领域,像 Swagger 这样的工具可以用来定义服务接口和数据Schema,并提供基本的客户端代码生成能力;不过对比完整成熟的RPC框架如 Apache Thrift 或 Google gRPC,绝大多数REST服务工具缺乏丰富的功能比如强类型支持,请求/响应验证,高效的数据负载编码和协议等等,这些功能是开发强壮高效的服务的必备能力。虽然 Thrift 和 gRPC 是业界广泛应用的RPC框架,但 Airbnb 都是基于 HTTP 服务的技术栈,如果要全部迁移,对我们而言是个灾难而不是生产力提升。因此在 Airbnb,我们采取了一个折中的办法,继续保持Dropwizard服务框架,引入Thrift作为服务接口定义语言IDL(Interface definition language),并将服务通信协议从JSON-over-HTTP改成 Thrift-over-HTTP,这样利用Thrift工具来生成不同语言的客户端代码(Ruby和Java)。

Airbnb 框架团队开发了一系列的自动代码生成工具以帮助开发人员仅仅关注核心的业务逻辑开发;以Java开发为例,开发人员在 .thrift 文件中定义服务API,基于该定义,可以自动生产服务端代码和RPC客户端代码,服务定义框架支持,强化了Airbnb 服务标准化和最佳实践;因此标准的Apache Thrift是远远不够的;Airbnb 团队扩展并定制了Thrift的编译器,使得它生成的代码支持微服务交互中常见的非功能需求,如上图所示,除了业务逻辑模块,其它部分代码都是框架自动生成;Airbnb团队相信这样的自动代码生成扩展是加速他们走向SOA的微服务技术栈的关键因素

由于开发人员非常熟悉 Dropwizard REST 服务框架,而且 Airbnb 也构建了很多常用的类库,但是 Dropwizard 利用了 Jersey 和 Jackson 库实现了JSON-over-HTTP的服务通信接口;为了无缝切换到 Thrift-over-HTTP的方式,Airbnb 团队扩展了 Dropwizard 以增加 Thrift 的数据负载(payload)类型,从而延续了开发人员体验。

  • 利用定制的Thrift Entity Provider实现了一个Thriftmedia type的新Dropwizard 服务资源类

  • Thrift压缩的二进制消息优化了服务间的消息传输,从而提高了性能;并同时支持 Ruby 和 Java 客户端

  • 在服务响应中同时支持JSON和Thrift数据类型,使得开发人员可以方便利用 Curl 和 Postman 调试服务接口

强类型API接口支持,引入 Thrift 作为服务接口定义语言,原生就支持清晰的服务API的定义和文档;而且,每一个数据Schema中定义的属性会在生成的Java或Ruby类定义中再一次被强类型严格强化;构建向下兼容的API由于强制的内置数据Schema支持而变得简单,谨慎地生成服务器和客户端代码,使得开发人员能够记住并考虑向后兼容的API变更迭代。

服务依赖与Thrift代码编译器集成,一个微服务开发不仅仅是实现自身的业务逻辑,还会通过引用其它服务的IDL文件;无论是本服务的接口定义文件还是引用的第三方服务接口定义文件,编译器都会很好的处理,自动生成依赖的服务的RPC客户端调用代码;

小结

为了保障新服务的安全可靠上线,以及功能完备性,可扩展性,Airbnb非常重视服务构建的标准化,尽量利用代码自动生成框架


参考资料:

  • https://medium.com/airbnb-engineering/

  • https://www.infoq.com/presentations/airbnb-soa-migration/ 


客户案例系列:

·   客户案例分析:Airbnb - 如何分析和衡量分布式支付系统的交易完整性

 

云架构师自我修养系列:


近期原创:


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存